Freeradius 3.2.10 环境搭建以及 PAP 和 CHAP 两种认证方式的测试

Freeradius 简介

是什么?

FreeRADIUS 是目前全球使用最广泛、功能最强大的开源 RADIUS 服务器软件。如果把网络比作一座企业大楼,FreeRADIUS 就是这座大楼的 “智能门禁系统控制器”。它专门用来解决网络世界里的 AAA 问题:

  • Authentication(认证):你是谁?(核对你的用户名、密码、证书或动态验证码)
  • Authorization(授权):你能干什么?(决定把你分配到哪个 VLAN、给你的网速限制是多少、允许你访问哪些子网)
  • Accounting(计费/审计):你干了什么?(记录你什么时候上线的、什么时候下线的、一共用了多少流量、在线多长时间)


能干什么?

在顶端看,FreeRADIUS 的功能可以划分为核心协议支持、认证源集成(数据面)、策略控制(控制面) 以及高级架构能力四个维度:

  • 核心 AAA 协议支持:
    • RADIUS & RadSec:传统的 UDP 认证/计费,以及基于 TLS 加密的 RADIUS(RadSec,用于跨信任域的安全传输)。
    • TACACS+(3.2+ 增强):不仅能做网络准入(RADIUS),还能做网络设备(思科、华为等交换机)的命令行审计和权限分级(Command Authorization)。
    • COA / Disconnect-Message:动态授权变更。可以在用户上网途中,强制将其踢下线或动态限速。
  • 极其恐怖的 “连接器” 能力(认证源集成):它能对接你公司现有的几乎任何身份系统。
    • 标准数据库:MySQL, PostgreSQL, Oracle, Redis。
    • 企业身份源:LDAP, Microsoft Active Directory (AD)。
    • 现代 Auth:REST API (HTTP JSON 请求)、gRPC、PAM。
    • 可编程脚本:支持直接嵌入 Python3、Perl、Lua 脚本来处理复杂的认证逻辑。
  • 控制面:EAP 与策略引擎。
    • 企业 Wi-Fi 与 802.1X 的基石:完美支持 EAP-PEAP、EAP-TLS(证书认证)、EAP-TTLS。
    • Unlang 策略语言:FreeRADIUS 自创的伪代码语言(类似 if/else),可以在不编译源码的情况下,任意修改请求包、判断属性、改写路由逻辑。


核心架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[ 终端设备 ]  (手机/电脑)

▼ (EAP / 802.1X 报文)
[ 无线AP / 交换机 ] (NAS - Network Access Server)

▼ (RADIUS 协议: 端口 1812 / 1813)
┌────────────────────────────────────────┐
│ FreeRADIUS 服务器 │
│ │
│ [ 1. 报文解析 ] │
│ [ 2. 策略判断 (Unlang) ] │
│ [ 3. 认证模块 (EAP/PEAP/MSCHAP) ] │
└───────────────────┬────────────────────┘

┌─────────────┼─────────────┐
▼ ▼ ▼
[ Active Directory ] [ MySQL ] [ OpenLDAP ]


集群架构

大多数商业交换机、无线控制器(AC)或胖 AP 本身就支持配置主/备 RADIUS 服务器(Primary / Secondary Server)。AP 默认把所有认证请求发给 FreeRADIUS 节点 A。如果 节点 A 挂了(心跳超时),AP 会自动将后续请求切到 FreeRADIUS 节点 B。但这种结构只有主备切换,无法做到流量的负载均衡(所有压力都在主节点)。一个标准的生产级 FreeRADIUS 集群通常由三层结构组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
             ┌───────────────────────┐
│ 无线 AP / 交换机 │ (NAS)
└───────────┬───────────┘


┌──────────────────────────────────┐
│ 负载均衡器 / VIP (例如 Keepalived)│ LVS / Keepalived + HAProxy
│ 或 RADIUS 代理 (Proxy) │
└────────────────┬─────────────────┘
│ (分发 UDP 1812/1813 流量)
┌─────────────┴─────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ FreeRADIUS 节点 1 │ │ FreeRADIUS 节点 2 │
│ (无状态, 配置完全一致)│ │ (无状态, 配置完全一致)│
└──────────┬─────────┘ └──────────┬─────────┘
│ │
└─────────────┬─────────────┘

┌─────────────────────────────────┐
│ 高可用后端 (数据库集群/AD/LDAP) │
└─────────────────────────────────┘

使用支持 UDP 报文分发的负载均衡器(如 LVS / Keepalived + HAProxy,或者硬件负载均衡 F5),将 RADIUS 流量按照轮询或哈希算法分发给后端的多个 FreeRADIUS 节点。难点与细节:

  • UDP 无连接特性:RADIUS 基于 UDP,负载均衡器必须开启会话保持(Session Persistence / IP Hash)因为 EAP/PEAP 认证需要经过多次 Request-Response 报文交互,同一个终端的整个 EAP 握手过程必须落到同一台 FreeRADIUS 节点上。
  • EAP 状态问题:如果在 PEAP 握手中间报文被甩到了另一台服务器,另一台服务器是没有上文 TLS 内存状态的,会导致认证直接 FAILURE。

FreeRADIUS 集群的关键注意事项:

  • 内层 TLS Session Cache(PEAP 重连优化): 如果启用了 PEAP-TLS,并且希望用户在多个节点之间切换时能够快速重连(Fast Resume),各 FreeRADIUS 节点的 TLS 缓存必须共享。通常使用 Memcached 或 Redis 模块来集中存储 TLS Session。
  • 后端 Session 状态与计费(Accounting & Radutmp):如果限制 “一个账号只能一人登录”,FreeRADIUS 需要知道当前谁在线。绝不能使用本地文件的 radutmp,必须使用集中式数据库(如 MySQL/PostgreSQL)的 radacct 表或 Redis 来统一记录用户的上线/下线状态。
  • 由于各节点配置需要完全一致(包括 clients.conf 里的共享密钥 secret、certs/ 证书文件、mods-enabled/ 模块),生产环境中通常结合 Git + CI/CD,或者 Ansible / Docker Swarm / K8s 来确保配置的一致性更新。


使用场景

  • Wi-Fi 认证(802.1X / EAP):你在公司连 Wi-Fi 时,不需要输入统一的傻瓜式密码,而是弹出一个窗口让你输入你自己的员工账号和密码(PEAP-MSCHAPv2)或者加载个人证书(EAP-TLS)。后台处理这个校验过程的,绝大多数就是 FreeRADIUS。
  • 运营商与宽带拨号(PPPoE / IPoE):你在家用的电信、联通宽带拨号上网,或者在酒店/机场连接需要手机号登录的 Web 认证网络(Captive Portal),后台通常是由 FreeRADIUS 配合数据库来计费和控制断网。
  • VPN 与远程接入:公司员工通过 OpenVPN、WireGuard 或 IPsec VPN 连入内网时,FreeRADIUS 负责对接公司的 LDAP/AD 域控进行二次身份验证。
  • 网络设备管理(TACACS+ / RADIUS):网络工程师登录交换机、路由器、防火墙进行配置时,集中管理哪些管理员有权限登录、做了哪些命令操作。


为什么它是行业标准

  • 极其庞大的生态与模块化:FreeRADIUS 就像积木一样。它的核心非常轻量,但拥有极其丰富的模块(mods-enabled/)。它可以轻松对接各种后端“账号库”:
    • 数据库:MySQL、PostgreSQL、Oracle、MongoDB…
    • 目录服务:微软 Active Directory (AD)、OpenLDAP、FreeIPA…
    • 现代认证:REST API、OAuth2、双因素认证(MFA/TOTP)…
  • 高性能与高并发:它采用 C 语言编写,性能强悍,能够轻松应对每秒成千上万次的并发认证请求,也是很多大型电信运营商的 preferred 选择。
  • 强大的策略引擎(Unlang):FreeRADIUS 拥有自己的伪代码脚本语言(Unlang),允许管理员在认证流程中写 if / else 逻辑。例如:“如果这个用户是财务部的,并且是在下班后登录,就把他分配到访客 VLAN 并限制网速为 10Mbps”。
  • 100% 开源且免费:相比思科的 ISE、Aruba 的 ClearPass 等动辄几十万上百万的商业 AAA 服务器,FreeRADIUS 是免费的,同时社区非常活跃。


搭建基本的调试环境

下面我们将基于 Docker 容器化技术,搭建一个包含 FreeRADIUS 服务端与独立测试客户端的双容器调试环境。在动手之前,请在你握紧这三把排查问题的 “手术刀”,它们将贯穿你后续的整个调优过程:

  • freeradius -X:看懂它的 Debug 日志,你就看懂了 RADIUS 协议的本质。

  • radtest 与 eapol_test:前者用于测试基础的 PAP/CHAP 认证及数据库(SQL/LDAP)连通性;后者则是专攻无线 WPA-Enterprise / EAP 协议的终极利器。

  • 模块化思维:FreeRADIUS 的核心配置全部在 mods-enabled/ 目录中。每次只启用和测试一个模块(例如:先测通 SQL,再集成 LDAP),切忌同时修改多个地方,否则会陷入多重错误的泥潭。


服务端环境初始化与挂载

第一步:创建工作目录与提取默认配置。在宿主机执行以下命令,快速拉取镜像并提取出干净的配置:

1
2
3
4
5
6
7
8
9
10
# 1. 创建测试工作目录
mkdir -p fr-test/config
cd fr-test

# 2. 快速启动一个临时容器,将官方默认配置复制到本地
docker run -d --name fr-tmp freeradius/freeradius-server:3.2.10
docker cp fr-tmp:/etc/raddb/. ./config/

# 3. 销毁临时容器
docker rm -f fr-tmp

第二步:编写 docker-compose.yml。在 fr-test 目录下创建 docker-compose.yml。这里我们采用双容器架构:一个作为 RADIUS 服务端(默认开启 -X 前台调试),另一个作为纯净的客户端(保持后台运行,专门用来跑测试工具)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
version: '3.8'

services:
radius-server:
image: freeradius/freeradius-server:3.2.10
container_name: radius-server
volumes:
- ./config:/etc/raddb
ports:
- "1812:1812/udp" # 认证端口 (Authentication)
- "1813:1813/udp" # 计费端口 (Accounting)
- "18120:18120/tcp" # 控制台管理端口
environment:
- TZ=Asia/Shanghai
command: ["freeradius", "-X"]

radius-client:
image: ubuntu:24.04
container_name: radius-client
restart: always
command: tail -f /dev/null

第三步:一键启动双容器环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 确保清理可能残留的旧同名容器
docker compose stop radius-client
docker compose rm -f radius-client
docker rm -f radius-server radius-client 2>/dev/null

# 启动环境 docker compose up -d radius-client
docker compose up -d

# 因为 docker-compose.yml 没有显式定义自定义网络,Compose 会自动为你创建一个名为 default 的默认网络。
# 网络名称 [项目文件夹名]_[网络名],此处 test_default。它的核心作用:提供容器间域名解析(DNS)
# Docker Compose 会把 docker-compose.yml 里定义的两个服务(radius-server 和 radius-client)都加入到这个网络中。
# 这就是为什么你在 radius-client 容器里可以直接用 radius-server 这个服务名作为主机名,而不需要硬编码具体的 IP 地址。
docker network prune
docker network ls
NETWORK ID NAME DRIVER SCOPE
58b5a043b206 bridge bridge local
872691d2005a fr-test_default bridge local
19797846162c host host local
f301bce95e3b none null local

docker network inspect fr-test_default
"Containers": {
"xxx...": {
"Name": "radius-server",
"IPv4Address": "172.18.0.3/16"
},
"xxx...": {
"Name": "radius-client",
"IPv4Address": "172.18.0.2/16"
}
}


配置独立测试客户端

现在我们要进入 radius-client 容器内部,将其武装成一个功能齐全的测试机。

第一步:进入客户端容器并换源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 进入客户端容器
docker exec -it radius-client bash

# 由于默认源在海外,为了加速工具链的安装,我们先将其替换为清华大学开源软件镜像源:
cat << 'EOF' > /etc/apt/sources.list.d/ubuntu.sources
Types: deb
URIs: http://mirrors.tuna.tsinghua.edu.cn/ubuntu/
Suites: noble noble-updates noble-backports
Components: main universe restricted multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg

Types: deb
URIs: http://mirrors.tuna.tsinghua.edu.cn/ubuntu/
Suites: noble-security
Components: main universe restricted multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF

# 更新软件源索引
apt-get update

第二步:安装基础依赖与基础测试工具。

1
2
3
# 我们首先安装编译所需的基础包,以及自带 radtest 工具的官方工具包:
apt-get update && apt-get install -y net-tools
apt-get install -y build-essential libssl-dev git curl freeradius-utils wpasupplicant

第三步:源码编译无线终极测试利器 eapol_test。eapol_test 是 wpa_supplicant 源码自带的一个隐藏测试程序,能够模拟真实的无线 AP 和客户端向 RADIUS 发起 PEAP/TLS/TTLS 等复杂的 EAP 认证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
apt-get update && apt-get install -y net-tools

# 1. 补充安装用于无线协议交互的 libnl 开发库
apt-get install -y libnl-3-dev libnl-genl-3-dev

# 2. 建立兼容性软链接(解决部分发行版编译时找不到旧版 libnl 库路径的问题)
ln -s /usr/lib/x86_64-linux-gnu/libnl-route-3.so.200 /usr/lib/x86_64-linux-gnu/libnl-route-3.so 2>/dev/null
ln -s /usr/lib/x86_64-linux-gnu/libnl-3.so.200 /usr/lib/x86_64-linux-gnu/libnl-3.so 2>/dev/null
ln -s /usr/lib/x86_64-linux-gnu/libnl-genl-3.so.200 /usr/lib/x86_64-linux-gnu/libnl-genl-3.so 2>/dev/null

# 3. 下载 wpa_supplicant 官方稳定版源码包并解压
curl -L https://w1.fi/releases/wpa_supplicant-2.11.tar.gz -o wpa_supplicant.tar.gz
tar -zxf wpa_supplicant.tar.gz
cd wpa_supplicant-2.11/wpa_supplicant

# 4. 生成编译配置文件并开启 eapol_test 组件
cat << 'EOF' > .config
CONFIG_EAPOL_TEST=y
CONFIG_INTERNAL_LIBTOMMATH=y
CONFIG_EAP_PEAP=y
CONFIG_EAP_TTLS=y
CONFIG_EAP_MD5=y
CONFIG_EAP_MSCHAPV2=y
CONFIG_EAP_GTC=y
CONFIG_EAP_TLS=y
EOF

# 5. 清理并开始编译
make clean
make eapol_test

# 6. 将编译好的二进制文件移入系统全局路径,并返回根目录
cp eapol_test /usr/local/bin/
cd /

# 7. 终极验证!
eapol_test -v


双向联调与验证

两端就绪后,我们立刻进行联调。通过观测服务端实时日志,验证整个环境的可用性。

第一步:打开服务端 “手术监视器”。在宿主机上新开一个终端,执行以下命令,实时挂起 FreeRADIUS 容器的 -X 调试日志:

1
docker logs -f radius-server 

第二步:使用 radtest 进行基础认证测试。回到 radius-client 容器内部的命令行中,使用默认的官方测试账号发起一次常规的 Access-Request:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 添加测试用户
$ vim config/users
owlias Cleartext-Password := "123456"
Reply-Message = "Hello, owlias! Network Auth Success!"

# 重启 radius-server
$ docker compose restart radius-server

# 格式:radtest [用户名] [密码] [RADIUS服务器地址] [NAS-Port] [共享密钥]
$ radtest owlias 123456 radius-server 0 testing123
Sent Access-Request Id 58 from 0.0.0.0:40547 to 172.18.0.3:1812 length 76
User-Name = "owlias"
User-Password = "123456"
NAS-IP-Address = 172.18.0.2
NAS-Port = 0
Message-Authenticator = 0x00
Cleartext-Password = "123456"
Received Access-Accept Id 58 from 172.18.0.3:1812 to 172.18.0.2:40547 length 76
Message-Authenticator = 0x689841cd944193ef27751df84c93c7e8
Reply-Message = "Hello, owlias! Network Auth Success!"


解释什么是 NAS

在 RADIUS 架构中,NAS 指的是“网络接入服务器”(Network Access Server)。简单来说,NAS 就是夹在“用户终端”和“FreeRADIUS 服务器”中间的那个“门卫/网关设备”。常见的 NAS 设备有:

  • 企业级 Wi-Fi AP / 无线控制器 (AC):你的手机连 Wi-Fi,AP 就是 NAS。
  • 网络交换机 (Switch):电脑插网线到交换机端口,交换机就是 NAS。
  • VPN 网关 / 路由器:你拨号连 VPN,VPN 服务器就是 NAS。
  • 运营商的 PPPoE 宽带接入设备 (BNG/BRAS):你在家宽带拨号,运营商的接入网关就是 NAS。

NAS 在认证流程中扮演的角色:

  • 翻译官:手机/电脑并不能直接向 RADIUS 服务器发报文。手机把账号密码发给 Wi-Fi AP,AP(作为 NAS)把这些信息打包成 RADIUS 协议格式,再发给 FreeRADIUS。
  • 执行者:FreeRADIUS 只是个决策大脑,回复说 “同意上线”。真正的物理开门动作(比如给这个端口通网、分配 VLAN、限制网速)是由 NAS(AP或交换机)来执行的。
1
2
[ 手机/电脑 ]  ───(1. 传账号密码)───>  [ NAS (如 Wi-Fi AP) ]  ───(2. 转成 RADIUS 报文)───>  [ FreeRADIUS ]
(用户终端) (门卫:看门的硬件) (数据中心:查数据库)

知道了 NAS 是交换机或 AP,NAS-Port 指的就是用户当前连接的是 NAS 设备的 “哪一个具体物理/逻辑端口”。

  • 交换机场景(很直观): 如果你的电脑插在企业交换机的第 5 号网线接口上,那么交换机发给 FreeRADIUS 的报文里就会带有:NAS-Port = 5。
  • 无线 Wi-Fi 场景:AP 上没有物理插槽,NAS-Port 常常是一个逻辑编号(比如第几个无线信道/虚拟接口),或者是无线网卡的关联 ID(Association ID)。
  • VPN 场景:的是当前的 VPN 虚拟隧道编号(比如第 3 个 VPN 连入会话)。


抓包及分析

在容器抓包

因为 RADIUS 报文是明文 UDP 传输(除了密码字段会加密),我们可以直接在客户端或服务端容器内使用经典的命令行抓包工具 tcpdump 抓取数据,保存为 xxx.pcap 文件,然后拿到宿主机上用 Wireshark 界面打开分析。

第一步:在服务端容器安装 tcpdump。

1
2
docker exec -it radius-server /bin/bash
apt-get install tcpdump

第二步:在服务端容器内执行抓包命令(监听 RADIUS 默认的 1812 认证端口和 1813 计费端口)。建议:因为我们的 /etc/raddb 目录是挂载到宿主机的 fr-test/config 的,把文件存在容器内的 /etc/raddb/ 下,宿主机对应的 fr-test/config/ 目录下就会实时同步出现这个 radius_test.pcap 文件。

1
tcpdump -i any udp port 1812 or port 1813 -w /etc/raddb/zdemo/radius_1812_pap_test.pcap

第三步:保持抓包命令运行,打开另一个终端进入 radius-client 容器发起认证:

1
2
docker exec -it radius-client bash
radtest owlias 123456 radius-server 0 testing123

第四步:回到抓包终端按 Ctrl + C 停止抓包。此时在宿主机的 fr-test/config/zdemo/radius_1812_pap_test.pcap 就是抓到的数据包,你可以直接双击用宿主机上安装的 Wireshark 打开!


Wireshark 实时抓包

如果你想体验 Wireshark 界面实时看到数据包滚动的感觉,可以直接让 Wireshark 去监听 Docker 自动创建的那个虚拟网桥网络(fr-test_default)。也许你在 macOS wireshark 过滤框输入 radius 或者 udp.port==1812 什么也没有抓到。 因为 macOS 宿主机并不是 Linux!Docker Desktop 实际上是在 macOS 后台开了一层隐形的 Linux 微型虚拟机(LinuxKit)。 当你在容器之间(radius-client – radius-server)发包时,数据包完全运行在这个 Linux 虚拟机的虚拟网卡上,根本不会流经 macOS 宿主机的物理网卡(如 en0 或 lo0)!所以你在宿主机直接打开 Wireshark 去抓包,自然什么都抓不到。


方案一:使用管道

把容器流量 “管道串联” 实时推送到 macOS 宿主机的 Wireshark(最优雅,实时看图):你可以利用 docker exec 配合 tcpdump,把容器内部捕获到的原始数据流,通过管道(Pipe)实时喂给 macOS 宿主机的 Wireshark 界面。打开 macOS 终端,直接粘贴并运行这一行命令:

1
2
3
4
# -w - 表示把抓到的二进制原始数据流不存磁盘,直接输出到控制台标准输出
# |(管道):把容器打出来的网络流,直接灌给 macOS 本地的 Wireshark 进程。
# Wireshark -k -i -:让宿主机的 Wireshark 从标准输入(-)实时接收数据包并立刻开始渲染(-k)。
$ docker exec radius-server tcpdump -i eth0 -U -w - "udp port 1812 or port 1813" | /Applications/Wireshark.app/Contents/MacOS/Wireshark -k -i -

运行命令后,macOS 会自动弹出一个 Wireshark 窗口。你再去 radius-client 容器里触发一次 radtest,宿主机的 Wireshark 界面就会实时滚动出绿色的 RADIUS 报文!在顶部的 Filter 框里写 radius 就能完美匹配过滤。


方案二:修改网卡

如果你不想用管道命令,而是想像平常一样打开 Wireshark 点选网卡直接抓,就需要强制把流量暴露到 macOS 宿主机本地的 Loopback(127.0.0.1)网卡。

修改 docker-compose.yml,把 radius-server 的端口映射绑定到宿主机的 127.0.0.1 上:

1
2
3
ports:
- "127.0.0.1:1812:1812/udp" # 绑定到本地环回
- "127.0.0.1:1813:1813/udp"

测试时使用 localhost 或 127.0.0.1

1
$ radtest owlias 123456 127.0.0.1 0 testing123

在 macOS 打开 Wireshark,选择 Loopback: lo0 这个网卡进行抓包。此时因为流量穿过了 macOS 的宿主机网络,在顶部的显示过滤器(Display Filter)输入 radius,就能 100% 抓取到了!


RADIUS PAP 认证分析

上述我们通过 radtest owlias 123456 127.0.0.1 0 testing123 发送的就是一个 radius PAP 认证(Password Authentication Protocol)。在 RADIUS 的 PAP 认证中,明文密码通过 MD5 异或(XOR)算法结合共享密钥(Secret)和随机验证字(Request Authenticator)加密。它的加密机制设计得很巧妙,既隐藏了明文,又通过随机数确保每次抓包看到的密文都不相同(哪怕两次发的是同一个密码)。

计算公式,假设明文密码(P)= 123456、共享密钥(S)= testing123、请求随机数(RA)= Wireshark 中 Access-Request 头部自带的 16 字节随机数 Authenticator

  • 第一步:补齐 16 字节块(Padding)。RADIUS 规定密码必须按 16 字节(128 bits)对齐。如果明文密码长度不足 16 字节,右侧必须补 0x00 (NULL);如果超过 16 字节,则切分成多个 16 字节块($P_1, P_2, \dots$)依次迭代计算。明文 123456 的 ASCII 十六进制为:31 32 33 34 35 36(6 字节)。补齐到 16 字节后的 $P_1$:$\text{P}_1 = \text{31 32 33 34 35 36 00 00 00 00 00 00 00 00 00 00}$
  • 第二步:生成掩码密钥块(Key Stream):将共享密钥 (S) 和随机数 (RA) 拼接起来,计算一次MD5 哈希,生成一个 16 字节的掩码 $b_1$:$b_1 = \text{MD5}(S + RA)$。由于 $RA$ 是客户端每次发送请求时随机生成的,所以哪怕密码和共享密钥完全不变,每次算出来的 $b_1$ 掩码也绝对不一样!
  • 第三步:异或运算(XOR)得到密文。将第一步的 $P_1$ 与第二步的 $b_1$ 进行按位异或($\oplus$),结果 $C_1$ 就是你在 Wireshark 里看到的十六进制密文:$C_1 = P_1 \oplus b_1$。如果密码很长(比如 20 字节),分成了 $P_1$ 和 $P_2$,后续块的计算会利用上一步的密文进行链式迭代(CBC 模式的思想):$b_2 = \text{MD5}(S + C_1)$,$C_2 = P_2 \oplus b_2$,最终将所有密文块拼接:$\text{密文} = C_1 + C_2 + \dots$
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class RadiusPapUtils {

/**
* 加密 PAP 密码 (返回 Hex 字符串)
*
* @param plainPassword 明文密码,例如 "123456"
* @param sharedSecret 共享密钥,例如 "testing123"
* @param requestAuthenticator 请求随机数 (32位的 Hex 字符串,16 字节),例如 "a1b2c3d4e5f678901234567890abcdef"
* @return 加密后的 User-Password Hex 字符串
*/
public static String getEncryptedPasswordHex(String plainPassword, String sharedSecret, String requestAuthenticator) {
byte[] secretBytes = sharedSecret.getBytes(StandardCharsets.UTF_8);
byte[] authBytes = hexToBytes(requestAuthenticator);
byte[] passBytes = plainPassword.getBytes(StandardCharsets.UTF_8);

// 1. 计算需要填充到的 16 字节整数倍长度
int paddedLength = passBytes.length;
if (paddedLength % 16 != 0) {
paddedLength += (16 - (paddedLength % 16));
}
if (paddedLength == 0) {
paddedLength = 16;
}

// 2. 补齐 0x00
byte[] paddedPass = new byte[paddedLength];
System.arraycopy(passBytes, 0, paddedPass, 0, passBytes.length);

byte[] encryptedBytes = new byte[paddedLength];
byte[] lastBlock = authBytes; // 第一块使用 Request Authenticator

try {
MessageDigest md5 = MessageDigest.getInstance("MD5");

// 3. 按 16 字节分块加密(支持任意长度密码)
for (int i = 0; i < paddedLength; i += 16) {
// b_i = MD5(Secret + lastBlock)
md5.reset();
md5.update(secretBytes);
md5.update(lastBlock);
byte[] b = md5.digest();

// C_i = P_i XOR b_i
for (int j = 0; j < 16; j++) {
encryptedBytes[i + j] = (byte) (paddedPass[i + j] ^ b[j]);
}

// 下一块的 lastBlock 使用当前算出的密文 C_i
lastBlock = new byte[16];
System.arraycopy(encryptedBytes, i, lastBlock, 0, 16);
}
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 算法不可用", e);
}

return bytesToHex(encryptedBytes);
}

/**
* 解密 PAP 密码
*
* @param encryptedPasswordHex 加密后的 User-Password (Hex 字符串)
* @param sharedSecret 共享密钥,例如 "testing123"
* @param requestAuthenticator 请求随机数 (32位的 Hex 字符串,16 字节)
* @return 解密后的明文密码
*/
public static String getDecryptedPassword(String encryptedPasswordHex, String sharedSecret, String requestAuthenticator) {
byte[] secretBytes = sharedSecret.getBytes(StandardCharsets.UTF_8);
byte[] authBytes = hexToBytes(requestAuthenticator);
byte[] cipherBytes = hexToBytes(encryptedPasswordHex);

int length = cipherBytes.length;
if (length == 0 || length % 16 != 0) {
throw new IllegalArgumentException("密文长度必须是 16 字节的整数倍");
}

byte[] plainBytes = new byte[length];
byte[] lastBlock = authBytes; // 第一块使用 Request Authenticator

try {
MessageDigest md5 = MessageDigest.getInstance("MD5");

// 按 16 字节分块解密
for (int i = 0; i < length; i += 16) {
// b_i = MD5(Secret + lastBlock)
md5.reset();
md5.update(secretBytes);
md5.update(lastBlock);
byte[] b = md5.digest();

// P_i = C_i XOR b_i
for (int j = 0; j < 16; j++) {
plainBytes[i + j] = (byte) (cipherBytes[i + j] ^ b[j]);
}

// 解密下一块时,需要用当前块的【密文】作为下一次 MD5 的输入
lastBlock = new byte[16];
System.arraycopy(cipherBytes, i, lastBlock, 0, 16);
}
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 算法不可用", e);
}

// 去除末尾补位填充的 0x00 字节
int realLength = length;
while (realLength > 0 && plainBytes[realLength - 1] == 0) {
realLength--;
}

return new String(plainBytes, 0, realLength, StandardCharsets.UTF_8);
}

private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}

private static byte[] hexToBytes(String hexString) {
int len = hexString.length();
if (len % 2 != 0) {
throw new IllegalArgumentException("Hex 字符串长度必须为偶数");
}
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hexString.charAt(i), 16) << 4)
+ Character.digit(hexString.charAt(i + 1), 16));
}
return data;
}

public static void main(String[] args) {
String plainPassword = "123456";
String sharedSecret = "testing123";
// 假设从 Wireshark 抓包看到的 16 字节 Request Authenticator (32个字符)
String requestAuthenticator = "0974dafac8aa204befb6b35a4de54479";

// 1. 加密
String encryptedHex = getEncryptedPasswordHex(plainPassword, sharedSecret, requestAuthenticator);
System.out.println("加密出的 Hex 密文: " + encryptedHex);

// 2. 解密
String decryptedPassword = getDecryptedPassword(encryptedHex, sharedSecret, requestAuthenticator);
System.out.println("解密出的明文密码: " + decryptedPassword);

// 验证结果
System.out.println("验证成功: " + plainPassword.equals(decryptedPassword));
}
}


RADIUS PAP 认证全景交互图

认证的全景图

PAP 是 RADIUS 中最古老、最简单,但也最直观的认证协议。它的核心特点是:单向认证(客户端认证服务端,但服务端不认证客户端)、一次握手(One-round trip)、明文传输(通过共享密钥与随机数 MD5 掩码加密密码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[ 用户终端 ]              [ NAS (网关/AP/交换机) ]           [ FreeRADIUS 服务器 ]
│ │ │
│── 1. 提交账号密码 (EAP/HTTP/802.1X) ──> │
│ │ │
│ │─── 2. 生成 16 字节随机数 (RA) ────>│
│ │─── 3. 计算 User-Password 密文 ───>│
│ │ │
│ │───── 4. UDP: Access-Request ────>│ (发送端口 1812)
│ │ │
│ │ │ ── 5. 校验 Secret & 签名
│ │ │ ── 6. 解密 User-Password
│ │ │ ── 7. 匹配后端/比对密码
│ │ │ ── 8. 生成 Response Auth
│ │ │
│ │<──── 9. UDP: Access-Accept ──────│ (或 Access-Reject)
│ │ │
│<── 10. 允许/拒绝网络接入 ────│ │


详细步骤拆解

阶段一:终端到 NAS(网关接入)

  • 用户提交凭据: 用户在 Portal 页面、VPN 客户端或连 Wi-Fi 时输入 User-Name = “owlias” 和 Password = “123456”。终端通过 HTTP/EAP/802.1X 协议发给 NAS(比如交换机或无线 AP)

阶段二:NAS 封装并发起 RADIUS 请求。NAS 收到用户的账号密码后,并不会直接转发明文,而是需要按照 RADIUS 规范构建一个 Access-Request (Code 1) UDP 报文:

  • 生成请求随机数(Request Authenticator, 简称 RA): NAS 随机生成一个 16 字节(128-bit)的随机数 RA,作为本次 RADIUS 报文头的 Authenticator 字段。防止重放攻击(Replay Attack),并作为后续加密密码的盐值(Salt)。
  • 加密密码(MD5 XOR):NAS 拿自己与 FreeRADIUS 事先配置好的 共享密钥(Shared Secret,如 testing123)和刚生成的 RA 做运算:
    • 掩码 $b_1 = \text{MD5}(\text{Secret} + \text{RA})$
    • 密文 $C_1 = P_1 \oplus b_1$(其中 $P_1$ 是右侧补零齐到 16 字节的明文密码)
    • 将 $C_1$ 放入属性列表中的 User-Password (Type 2)。
  • 发送 Access-Request 报文:NAS 通过 UDP 1812 端口发往 FreeRADIUS,报文中包含:
    • Code: 1 (Access-Request)
    • Identifier: 随机匹配 ID(如 100,用于匹配后期的 Response)
    • Authenticator: 生成的 16 字节 RA
    • Attributes:
      • User-Name: “owlias”
      • User-Password: 加密后的十六进制密文
      • NAS-IP-Address: NAS 自身的 IP
      • NAS-Port: 用户接入的具体端口号

阶段三:FreeRADIUS 服务端接收与管道处理。报文到达 FreeRADIUS 后,进入我们之前提到的生命周期管道:

  • 安全校验与识别(Client Check):
    • FreeRADIUS 收到 UDP 报文,先查 clients.conf:发起方的 IP 是否在允许的 NAS 列表内?如果不在,直接丢弃报文(Silent Discard)。如果在,取出对应的 Shared Secret,准备解密。
    • authorize 管道与密码还原:
      • 解密密码:取报文头的 RA,重新计算 $b_1 = \text{MD5}(\text{Secret} + \text{RA})$,推导出明文密码 $P_1 = C_1 \oplus b_1$。
      • 查找用户:FreeRADIUS 拿着 User-Name = “owlias” 去查数据库或 users 文件,找到该用户的预存真实密码或哈希值(例如查到 Cleartext-Password = “123456”)。
    • authenticate 管道(密码比对):FreeRADIUS 的 pap 模块将 “解密出的明文密码” 与 “数据库里查到的密码” 做字符串比对(或 Hash 比对)。如果一致,判定认证成功,返回 ok。如果不一致,判定认证失败,准备构建 Access-Reject。

阶段四:FreeRADIUS 响应与决策防篡改。

  • 生成响应验证字(Response Authenticator): 为了防止黑客在网络中伪造响应包(比如黑客截获后伪造一个 Access-Accept 骗过交换机),FreeRADIUS 必须对响应报文进行签名:$\text{Response Authenticator} = \text{MD5}(\text{Code} + \text{ID} + \text{Length} + \text{RA} + \text{Attributes} + \text{Secret})$
  • 发送响应报文:
    • 认证成功:发送 Access-Accept (Code 2),可以附带授权属性(如 Reply-Message,分配给用户的 VLAN ID Tunnel-Private-Group-Id,网速限制属性等)。
    • 认证失败:发送 Access-Reject (Code 3),拒绝用户上线。

阶段五:NAS 执行接入控制

  • NAS 收到响应后,用同样的算法校验 Response Authenticator 的签名是否合法。
  • 如果合法且是 Access-Accept,NAS 放行端口,把用户加入对应 VLAN,用户成功通网!


PAP 认证的优缺点

  • 优点:极其简单高效。无复杂多次 TLS 握手,一个 Request、一个 Response 搞定,数据库校验性能极高。
  • 致命缺点:弱安全性(密码易被破解)。MD5 算法已经被证实不安全。只要在网络中抓到包,且知道共享密钥(或者对 Shared Secret 进行字典爆破),就能直接通过异或运算还原出明文密码。
  • 使用的建议:绝不建议直接在公共网络中使用。仅用于完全隔离的安全内网/专线,或者作为外层已建立 TLS 隧道(如 PEAP-GTC / TTLS-PAP)内部的二次认证协议。


PAP 方案的改进 CHAP

Portal+AC+Radius 案例

既然 PAP 发送明文密码(仅靠弱 MD5 加密)不安全,人们自然想到了用挑战/响应(Challenge-Response)机制的协议——比如 CHAP(Challenge Handshake Authentication Protocol)。

CHAP 最大的革新在于:在整个网络认证过程中,用户的密码永远不需要在网络线上传输!我们以一个更贴近实际组网(校园网/企业网常见的 Portal+AC+RADIUS 架构)的场景,报文比单纯的 PPP CHAP 多了一层——Portal Server 与 AC 之间还有一套 Portal 协议(国内厂商如华为/H3C 常用,UDP 2000端口,并非 IETF 标准,但字段含义业界基本通用)。

① 客户端访问外网 → 被重定向
用户浏览器发起 HTTP 请求,AC (或与之联动的接入设备)检测到该终端 MAC/IP 未认证,回一个 302,把浏览器导向 Portal Server 的登录页 URL (URL 里通常带上原始访问地址、AC 地址、NAS-ID 等参数,便于后续关联)。

② Portal Server → AC:REQ_CHALLENGE (Portal 协议,通常 UDP 2000)
关键字段: Type=1(REQ_CHALLENGE)、SerialNo (序列号用于后续请求/响应配对)、ReqID、UserIP (待认证终端 IP)、AuthType=CHAP。

③ AC → Portal Server:ACK_CHALLENGE
关键字段: Type=2、SerialNo (原样带回)、ErrCode=0、Challenge (AC 生成的 16 字节随机挑战码——这是整条链路里唯一的 “随机源”)。

④ Portal Server 下发登录页给浏览器
把 Challenge 嵌入登录页(HTML/JS)一并推给浏览器,此时 Challenge 还没做任何运算。

⑤ 浏览器提交表单 → Portal Server
浏览器本地执行: CHAP-Password = MD5( (byte) ReqID + 明文密码 + Challenge ) 其中“+”表示字节数组的拼接。提交的字段是计算之后的MD5摘要。这是全流程中密码明文唯一 “存在” 的地方——仅在浏览器本地内存中参与运算,从未上过网线。

⑥ Portal Server → AC:REQ_AUTH
关键字段: Type=3、SerialNo/ReqID (关联步骤②③的会话)、UserName、Password 字段 (装的是⑤算出的 CHAP-Password 摘要,不是明文)、Challenge(原样带回)、AuthType=CHAP。

⑦ AC → FreeRADIUS:Access-Request (RADIUS 协议,UDP 1812)。AC 把 Portal 报文字段映射成标准 RADIUS 属性:

  • User-Name(1)= 用户名
  • CHAP-Password(3)= 1 字节 CHAP Identifier + 16 字节摘要,这里的 CHAP Identifier=(byte)(ReqID)
  • CHAP-Challenge(60)= AC 生成的那个随机挑战码
  • NAS-IP-Address(4)、NAS-Port 等设备信息

⑧ 服务器端比对(FreeRADIUS 内部)
FreeRADIUS 从用户数据库 users 文件中取出该账号的 Cleartext-Password,用同样公式重新计算 MD5(CHAP Ident + 明文密码 + CHAP-Challenge)与请求包里 CHAP-Password 属性中的摘要部分逐字节比对。这一步要求服务器必须能拿到密码明文或可逆形式——这也是 CHAP 相对存储更简单的哈希方案(如只存 SHA 摘要)的代价。

⑨ FreeRADIUS → AC:Access-Accept / Access-Reject

  • Accept 关键字段: Service-Type、Session-Timeout、Class(计费关联用)、可选 Framed-IP-Address
  • Reject 关键字段: Reply-Message (失败原因文本)

⑩ AC → Portal Server:ACK_AUTH
关键字段: Type=4、SerialNo/ReqID、ErrCode(0=成功)、在线时长/流量限制等策略字段。最后,Portal Server 给浏览器返回认证成功/失败页面,AC 侧同步放行该 IP/MAC 的上网权限。

需要说明的是,Portal 协议本身不是 IETF 标准,字段名称(Type/SerialNo/ReqID 等)是华为、H3C 等厂商私有实现里的常见叫法,不同厂商在细节上会有差异,但 “AC 下发 Challenge → 浏览器本地做 MD5 摘要 → 逐跳透传摘要而非密码” 这个核心链路是一致的。RADIUS 侧的 CHAP-Password(属性3)和 CHAP-Challenge(属性60)则是 RFC 2865/2869 里的标准属性。


RADIUS CHAP 抓包

我们继续使用 wireshark 进行抓包测试,在测试端发送一个chap模拟请求:

1
$ radtest -t chap owlias 123456 radius-server 0 testing123

抓包内容:


CHAP 认证算法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class RadiusChapUtils {

/**
* 计算 CHAP-Password (MD5 响应值)
*
* 计算公式: MD5( (byte)ReqID + CleartextPassword + Challenge )
*
* @param reqId 请求标识符 Identifier (0 ~ 255,例如 1 或 100)
* @param plainPassword 明文密码,例如 "password123"
* @param challengeHex 挑战字 Challenge 的 Hex 字符串 (例如 16 字节的 Hex "a1b2c3d4e5f678901234567890abcdef")
* @return 计算得到的 16 字节 MD5 哈希 Hex 字符串 (32 位十六进制)
*/
public static String getMD5ChapPassword(short reqId, String plainPassword, String challengeHex) {
byte[] idByte = new byte[]{ (byte) (reqId & 0xFF) };
byte[] passBytes = plainPassword.getBytes(StandardCharsets.UTF_8);
byte[] challengeBytes = hexToBytes(challengeHex);

try {
MessageDigest md5 = MessageDigest.getInstance("MD5");

// 按照规范依次拼接字节数组: (byte)ReqID + CleartextPassword + Challenge
md5.update(idByte);
md5.update(passBytes);
md5.update(challengeBytes);

byte[] chapHash = md5.digest();
return bytesToHex(chapHash);

} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 算法不可用", e);
}
}

private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}

private static byte[] hexToBytes(String hexString) {
int len = hexString.length();
if (len % 2 != 0) {
throw new IllegalArgumentException("Hex 字符串长度必须为偶数");
}
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hexString.charAt(i), 16) << 4)
+ Character.digit(hexString.charAt(i + 1), 16));
}
return data;
}

public static void main(String[] args) {
short reqId = 223; // 0xdf CHAP-Ident = byte(reqId)
String plainPassword = "123456";
String challengeHex = "b88c48aa832fc3704a0d64847e104f03";
String chapPasswordHex = getMD5ChapPassword(reqId, plainPassword, challengeHex);

System.out.println("CHAP-Ident (1 Byte Hex): " + String.format("%02x", reqId & 0xFF));
System.out.println("CHAP 计算得到的 16 字节 Response MD5 (Hex): " + chapPasswordHex); // ac8a7d247cc0a15abeb2ed172c208ea1
}
}


CHAP 安全吗

那么,CHAP 能在公网上安全传输吗?它足够安全吗?答案是:CHAP 比 PAP 安全得多(密码不再在网络上传输),但直接裸奔在公网上,它依然不够安全!现代公网传输真正的终极替代方案是 TLS 隧道(如 EAP-TTLS / PEAP)。之所以说 CHAP 还不够安全,是因为:

  • 为了能够计算摘要进行比对,CHAP 必须在数据库存储明文密码(或者能够反推出明文的密码)。
  • 攻击者只要能抓取一个包,就能够获取到摘要、ReqID、Challenge,利用这些信息他就可以在本地进行每秒几十亿次的爆破,对于现代密码学,MD5 已经不算安全了。